Skip to content

Memory 管理的三大策略:截断、总结、检索

我们给大模型扩展了 Tool,让它可以做一些事情而不只是回答问题;给大模型扩展了 RAG,基于 query 获取向量数据库里相关的知识放入 prompt。但这些其实都依赖一个东西:Memory

大模型是无状态的,你这次调用和下次调用没区别,它并不知道之前你问了什么、回答了什么。有同学说,不对啊,我明明可以基于上次的回答继续问——这是因为你已经做了 Memory 管理。

记得我们之前写的这个循环么:在 messages 数组放入了 SystemMessage,告诉大模型它的角色、功能,然后放入了 HumanMessage(用户的问题),然后 invoke 大模型,这是第一次调用。大模型返回了 AIMessage 和 tool_calls 信息,我们基于 tool_calls 去调用工具,然后把结果封装成 ToolMessage 也放入 messages 数组。循环继续调用大模型,直到不再有 tool_calls,就把那个 AIMessage 返回,这就是最终回复。

这个过程我们循环调用了多次大模型。你觉得大模型是怎么知道之前问过什么、回答过什么的?就是基于 messages 数组,也就是 Memory。如果不做 Memory 管理,大模型根本不知道之前回答过什么,所以说它是无状态的。

但这种 messages 数组不断 push 的 Memory 管理机制显然不靠谱:大模型的上下文大小是有限的(比如 GPT-4o 大概是 200k token),不管这个限制多大,当你无限往 memory 里增加 message 的时候,总是会超的。所以我们要学一些 Memory 的管理策略。

先不看有哪些方案,你自己考虑下应该怎么做:

  • 可以只保留最近的几条 message,之前的舍弃掉
  • 直接舍弃之前的也不好,可以对之前的做一些总结,保留这个总结和最近的几条 message
  • 可以用我们刚学的向量数据库,根据语义检索之前的 message

没错,主流的也就是这三种思路:截断、总结、检索

其实你每天都在用 memory 的这些策略:你用 Cursor 或者 Claude Code 的时候,会有一个 token 的计数,当达到的时候,会触发总结,然后开始新的一轮计数。Cursor 达到上下文限制会自动触发总结;Claude Code 达到限制自动触发总结,或者也可以 /compact 手动总结(compact 是压实压紧的意思)。

Memory 的 API 演进

还有一个问题,就是 messages 存在哪。现在都是存在内存中的,而实际上可以做持久化,存在文件、Redis、数据库等。

所以之前 Memory 一共有两个维度的 API:

  • ChatMessageHistory 相关:它是存储层,也就是 messages 存在哪,可以是内存、文件、数据库等
  • 逻辑层:也就是截断、总结、向量数据库这些,每个 xxMemory 类都有一个 chatHistory 属性,关联着存储层

但是,这些 memory 的 API,已经全部被废弃了(移到了 @langchain/classic 这个包)。因为它们不够灵活——像之前提到的截断、总结、检索(向量数据库)完全可以自己实现:截断就是根据总 token 数量来保留最近的 message;总结就是调用大模型对之前的 message 生成一个摘要;检索向量数据库就是之前的 RAG 流程,只不过用来对 message 做语义检索。用 memory 这些 API 反而更黑盒而且不灵活,所以新版干脆都去掉了。

但是加了一个 trimMessages 的 API,可以根据 token 来截断消息。所以现在 Memory 相关就剩下了 history + trimMessages 的 API。

我们来写代码试一遍:

bash
mkdir memory-test
cd memory-test
npm init -y
pnpm install dotenv @langchain/core @langchain/openai @langchain/community langchain

存储层:内存和文件

内存存储

创建 src/history-test.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

async function inMemoryDemo() {
  const history = new InMemoryChatMessageHistory();
  const systemMessage = new SystemMessage(
    "你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧。"
  );

  // 第一轮对话
  console.log("[第一轮对话]");
  const userMessage1 = new HumanMessage("你今天吃的什么?");
  await history.addMessage(userMessage1);

  const messages1 = [systemMessage, ...(await history.getMessages())];
  const response1 = await model.invoke(messages1);
  await history.addMessage(response1);

  console.log(`用户: ${userMessage1.content}`);
  console.log(`助手: ${response1.content}\n`);

  // 第二轮对话(基于历史记录)
  console.log("[第二轮对话 - 基于历史记录]");
  const userMessage2 = new HumanMessage("好吃吗?");
  await history.addMessage(userMessage2);

  const messages2 = [systemMessage, ...(await history.getMessages())];
  const response2 = await model.invoke(messages2);
  await history.addMessage(response2);

  console.log(`用户: ${userMessage2.content}`);
  console.log(`助手: ${response2.content}\n`);

  // 展示所有历史消息
  console.log("[历史消息记录]");
  const allMessages = await history.getMessages();
  console.log(`共保存了 ${allMessages.length} 条消息:`);
  allMessages.forEach((msg, index) => {
    const type = msg.type;
    const prefix = type === 'human' ? '用户' : '助手';
    console.log(` ${index + 1}. [${prefix}]: ${msg.content.substring(0, 50)}...`);
  });
}

inMemoryDemo().catch(console.error);

InMemoryChatMessageHistory 来管理 message,放到内存里,用 addMessage 添加 HumanMessage 和 AIMessage,分别调用了两次大模型。跑一下可以看到:第一次调用大模型,它回答了红烧肉、冬阴功汤;第二次再和它对话,它能知道之前聊过的内容,接着聊。

我们之前是用 messages 数组实现的,现在换成了 InMemoryChatMessageHistory 的 API。

文件存储:长时记忆

保存在文件里也是可以的。你看 Cursor 就是把对话过程持久化了,随时可以找到一个之前的聊天继续聊——这就是 message 持久化的好处,也叫做长时记忆(LTM,long-term memory)。相应的,内存中那种叫短时记忆(short-term memory)

试一下存到文件的长时记忆,创建 src/history-test2.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { FileSystemChatMessageHistory } from "@langchain/community/stores/message/file_system";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import path from "node:path";

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

async function fileHistoryDemo() {
  // 指定存储文件的路径
  const filePath = path.join(process.cwd(), "chat_history.json");
  const sessionId = "user_session_001";
  const systemMessage = new SystemMessage(
    "你是一个友好的做菜助手,喜欢分享美食和烹饪技巧。"
  );

  console.log("[第一轮对话]");
  const history = new FileSystemChatMessageHistory({
    filePath: filePath,
    sessionId: sessionId,
  });
  const userMessage1 = new HumanMessage("红烧肉怎么做");
  await history.addMessage(userMessage1);

  const messages1 = [systemMessage, ...(await history.getMessages())];
  const response1 = await model.invoke(messages1);
  await history.addMessage(response1);

  console.log(`用户: ${userMessage1.content}`);
  console.log(`助手: ${response1.content}`);
  console.log(`✓ 对话已保存到文件: ${filePath}\n`);

  console.log("[第二轮对话]");
  const userMessage2 = new HumanMessage("好吃吗?");
  await history.addMessage(userMessage2);

  const messages2 = [systemMessage, ...(await history.getMessages())];
  const response2 = await model.invoke(messages2);
  await history.addMessage(response2);

  console.log(`用户: ${userMessage2.content}`);
  console.log(`助手: ${response2.content}`);
  console.log(`✓ 对话已更新到文件\n`);
}

fileHistoryDemo().catch(console.error);

第一轮对话问红烧肉怎么做,第二轮接着问好吃吗,对话内容会保存到 chat_history.json 文件里。

然后我们再创建第三个文件,加载那个文件里的历史 message,继续问需要的食材(第三轮对话):

js
const restoredHistory = new FileSystemChatMessageHistory({
  filePath: filePath,
  sessionId: sessionId,
});

const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件恢复了 ${restoredMessages.length} 条历史消息:`);

const userMessage3 = new HumanMessage("需要哪些食材?");
await restoredHistory.addMessage(userMessage3);

const messages3 = [systemMessage, ...(await restoredHistory.getMessages())];
const response3 = await model.invoke(messages3);
await restoredHistory.addMessage(response3);

可以看到,第一轮、第二轮对话都被持久化保存到了文件里,后面可以从文件里恢复前两轮对话,继续第三轮。基于这个完全可以实现 Cursor 这种功能。

ChatMessageHistory 的 API 只是用来存储 message,接下来实现那三种策略。

策略一:截断

创建 src/memory/truncation-memory.mjs

js
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, AIMessage, trimMessages } from "@langchain/core/messages";
import { getEncoding } from "js-tiktoken";

// ========== 1. 按消息数量截断 ==========
async function messageCountTruncation() {
  const history = new InMemoryChatMessageHistory();
  const maxMessages = 4;
  const messages = [
    { type: 'human', content: '我叫张三' },
    { type: 'ai', content: '你好张三,很高兴认识你!' },
    { type: 'human', content: '我今年 25 岁' },
    { type: 'ai', content: '25 岁正是青春年华,有什么我可以帮助你的吗?' },
    { type: 'human', content: '我喜欢编程' },
    { type: 'ai', content: '编程很有趣!你主要用什么语言?' },
    { type: 'human', content: '我住在北京' },
    { type: 'ai', content: '北京是个很棒的城市!' },
    { type: 'human', content: '我的职业是软件工程师' },
    { type: 'ai', content: '软件工程师是个很有前景的职业!' },
  ];
  // 添加所有消息
  for (const msg of messages) {
    if (msg.type === 'human') {
      await history.addMessage(new HumanMessage(msg.content));
    } else {
      await history.addMessage(new AIMessage(msg.content));
    }
  }
  const allMessages = await history.getMessages();

  // 按消息数量截断:保留最近 maxMessages 条消息
  const trimmedMessages = allMessages.slice(-maxMessages);
  console.log(`保留消息数量: ${trimmedMessages.length}`);
  console.log("保留的消息:", trimmedMessages.map(m => `${m.constructor.name}: ${m.content}`));
}

// 计算消息数组的总 token 数量
function countTokens(messages, encoder) {
  let total = 0;
  for (const msg of messages) {
    const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
    total += encoder.encode(content).length;
  }
  return total;
}

// ========== 2. 按 token 数量截断(使用 js-tiktoken 计数) ==========
async function tokenCountTruncation() {
  const history = new InMemoryChatMessageHistory();
  const maxTokens = 100; // 限制最多 100 个 token

  const enc = getEncoding("cl100k_base");
  const messages = [
    { type: 'human', content: '我叫李四' },
    { type: 'ai', content: '你好李四,很高兴认识你!' },
    { type: 'human', content: '我是一名设计师' },
    { type: 'ai', content: '设计师是个很有创造力的职业!你主要做什么类型的设计?' },
    { type: 'human', content: '我喜欢艺术和音乐' },
    { type: 'ai', content: '艺术和音乐都是很好的爱好,它们能激发创作灵感。' },
    { type: 'human', content: '我擅长 UI/UX 设计' },
    { type: 'ai', content: 'UI/UX 设计非常重要,好的用户体验能让产品更成功!' },
  ];
  for (const msg of messages) {
    if (msg.type === 'human') {
      await history.addMessage(new HumanMessage(msg.content));
    } else {
      await history.addMessage(new AIMessage(msg.content));
    }
  }
  const allMessages = await history.getMessages();

  // 使用 trimMessages API:使用 js-tiktoken 计算 token 数量
  const trimmedMessages = await trimMessages(allMessages, {
    maxTokens: maxTokens,
    tokenCounter: async (msgs) => countTokens(msgs, enc),
    strategy: "last", // 保留最近的消息
  });

  // 计算实际 token 数用于显示
  const totalTokens = countTokens(trimmedMessages, enc);
  console.log(`总 token 数: ${totalTokens}/${maxTokens}`);
  console.log(`保留消息数量: ${trimmedMessages.length}`);
  console.log("保留的消息:", trimmedMessages.map(m => {
    const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
    const tokens = enc.encode(content).length;
    return `${m.constructor.name} (${tokens} tokens): ${content}`;
  }).join('\n '));
}

async function runAll() {
  await messageCountTruncation();
  await tokenCountTruncation();
}

runAll().catch(console.error);

这里有两种计数逻辑:第一种是消息条数,直接 slice 就行;第二种是 token 数量,用 trimMessages 的 API,用 js-tiktoken 这个包来计数。

bash
pnpm install js-tiktoken

跑一下可以看到:第一次根据数量截取了 4 条最近的 message,第二次根据 token 来截取。之后把截取的 messages 传给大模型调用就行了。这就是第一种策略,截断

策略二:总结

创建 src/memory/summarization-memory.mjs

js
import 'dotenv/config';
import { ChatOpenAI } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, SystemMessage, AIMessage, getBufferString } from "@langchain/core/messages";

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

// ========== 总结策略演示 ==========
async function summarizationMemoryDemo() {
  const history = new InMemoryChatMessageHistory();
  const maxMessages = 6; // 超过 6 条消息时触发总结
  const messages = [
    { type: 'human', content: '我想学做红烧肉,你能教我吗?' },
    { type: 'ai', content: '当然可以!红烧肉是一道经典的中式菜肴。首先需要准备五花肉、冰糖、生抽、老抽、料酒等材料。' },
    { type: 'human', content: '五花肉需要切多大块?' },
    { type: 'ai', content: '建议切成 3-4 厘米见方的块,这样既容易入味,口感也更好。切好后可以用开水焯一下。' },
    { type: 'human', content: '炒糖色的时候有什么技巧吗?' },
    { type: 'ai', content: '炒糖色是关键步骤。用小火烧,等冰糖完全融化变成焦糖色,冒小泡时就可以下肉了。' },
    { type: 'human', content: '需要炖多长时间?' },
    { type: 'ai', content: '一般需要炖 40-60 分钟,用小火慢炖,直到肉变得软糯入味。' },
    { type: 'human', content: '最后收汁的时候要注意什么?' },
    { type: 'ai', content: '收汁时要用大火,不断翻动,让汤汁均匀包裹在肉块上。看到汤汁变得浓稠就可以了。' },
  ];
  for (const msg of messages) {
    if (msg.type === 'human') {
      await history.addMessage(new HumanMessage(msg.content));
    } else {
      await history.addMessage(new AIMessage(msg.content));
    }
  }
  const allMessages = await history.getMessages();

  console.log(`原始消息数量: ${allMessages.length}`);

  // 如果消息过多,触发总结
  if (allMessages.length >= maxMessages) {
    const keepRecent = 2; // 保留最近 2 条消息

    // 分离要保留的消息和要总结的消息
    const recentMessages = allMessages.slice(-keepRecent);
    const messagesToSummarize = allMessages.slice(0, -keepRecent);

    console.log("\n💡 历史消息过多,开始总结...");
    console.log(`📝 将被总结的消息数量: ${messagesToSummarize.length}`);
    console.log(`📝 将被保留的消息数量: ${recentMessages.length}`);

    // 总结将被丢弃的旧消息
    const summary = await summarizeHistory(messagesToSummarize);

    // 清空历史消息,只保留最近的消息
    await history.clear();
    for (const msg of recentMessages) {
      await history.addMessage(msg);
    }

    console.log(`\n保留消息数量: ${recentMessages.length}`);
    console.log(`\n总结内容(不包含保留的消息): ${summary}`);
  } else {
    console.log("\n消息数量未超过阈值,无需总结");
  }
}

summarizationMemoryDemo().catch(console.error);

// 总结历史对话的函数
async function summarizeHistory(messages) {
  if (messages.length === 0) return "";

  const conversationText = getBufferString(messages, {
    humanPrefix: "用户",
    aiPrefix: "助手",
  });

  const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;

  const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
  return summaryResponse.content;
}

有 10 条消息,我们只保留最近的 2 条,之前的用 LLM 做总结。这里用到了 getBufferString 的 API,它可以给 HumanMessage、AIMessage 等加上不同的前缀来格式化。

跑一下可以看到:之前的 8 条内容做了总结,然后最近的 2 条保留。这样总结后再继续聊,token 消耗就少了。

当然,更常用的是根据 token 来触发总结,而不是消息条数(src/memory/summarization-memory2.mjs):超过 maxTokens(200)触发总结,从后往前累加消息,保留最近的消息直到达到 keepRecentTokens(80),其余做总结。逻辑和按条数一样,只是触发条件换成了 token 计数——我们每天用的 Cursor、Claude Code 就是这种策略。

策略三:检索

先把 Milvus 跑起来,用代码创建集合、插入数据(src/memory/insert-conversations.mjs):

js
import "dotenv/config";
import { MilvusClient, DataType, MetricType, IndexType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from "@langchain/openai";

const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: 'text-embedding-v3',
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

const client = new MilvusClient({
  address: 'localhost:19530'
});

async function getEmbedding(text) {
  const result = await embeddings.embedQuery(text);
  return result;
}

async function main() {
  try {
    console.log('连接到 Milvus...');
    await client.connectPromise;
    console.log('✓ 已连接\n');

    // 创建集合
    await client.createCollection({
      collection_name: COLLECTION_NAME,
      fields: [
        { name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
        { name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM },
        { name: 'content', data_type: DataType.VarChar, max_length: 5000 },
        { name: 'round', data_type: DataType.Int64 },
        { name: 'timestamp', data_type: DataType.VarChar, max_length: 100 }
      ]
    });
    // 创建索引
    await client.createIndex({
      collection_name: COLLECTION_NAME,
      field_name: 'vector',
      index_type: IndexType.IVF_FLAT,
      metric_type: MetricType.COSINE
    });
    // 加载集合
    await client.loadCollection({ collection_name: COLLECTION_NAME });

    // 插入对话数据
    const conversations = [
      {
        id: 'conv_001',
        content: '用户: 我叫赵六,是一名数据科学家\n助手: 很高兴认识你,赵六!数据科学是一个很有趣的领域。',
        round: 1,
        timestamp: new Date().toISOString()
      },
      {
        id: 'conv_002',
        content: '用户: 我最近在研究机器学习算法\n助手: 机器学习确实很有意思,你在研究哪些算法呢?',
        round: 2,
        timestamp: new Date().toISOString()
      },
      {
        id: 'conv_003',
        content: '用户: 我喜欢打篮球和看电影\n助手: 运动和文化娱乐都是很好的爱好!',
        round: 3,
        timestamp: new Date().toISOString()
      },
      {
        id: 'conv_004',
        content: '用户: 我周末经常去电影院\n助手: 看电影是很好的放松方式。',
        round: 4,
        timestamp: new Date().toISOString()
      },
      {
        id: 'conv_005',
        content: '用户: 我的职业是软件工程师\n助手: 软件工程师是个很有前景的职业!',
        round: 5,
        timestamp: new Date().toISOString()
      }
    ];

    console.log('生成向量嵌入...');
    const conversationData = await Promise.all(
      conversations.map(async (conv) => ({
        ...conv,
        vector: await getEmbedding(conv.content)
      }))
    );

    const insertResult = await client.insert({
      collection_name: COLLECTION_NAME,
      data: conversationData
    });
    console.log(`✓ 已插入 ${insertResult.insert_cnt} 条记录\n`);
  } catch (error) {
    console.error('错误:', error.message);
  }
}

main();

创建集合 conversations 用来保存对话记录(id/vector/content/round/timestamp),然后插入一些数据。这样,我们就把每轮对话格式化后存到了向量数据库里,记录了对话的时间、轮次。

接下来对话的时候,就可以用 RAG 来检索之前的对话内容了(src/memory/retrieval-memory.mjs):

js
import 'dotenv/config';
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { HumanMessage } from "@langchain/core/messages";

const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: 'text-embedding-v3',
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  dimensions: VECTOR_DIM
});

const client = new MilvusClient({
  address: 'localhost:19530'
});

async function getEmbedding(text) {
  const result = await embeddings.embedQuery(text);
  return result;
}

/**
 * 从 Milvus 中检索相关的历史对话
 */
async function retrieveRelevantConversations(query, k = 2) {
  try {
    const queryVector = await getEmbedding(query);
    const searchResult = await client.search({
      collection_name: COLLECTION_NAME,
      vector: queryVector,
      limit: k,
      metric_type: MetricType.COSINE,
      output_fields: ['id', 'content', 'round', 'timestamp']
    });
    return searchResult.results;
  } catch (error) {
    console.error('检索对话时出错:', error.message);
    return [];
  }
}

/**
 * 策略 3: 检索(Retrieval)
 * 使用 Milvus 向量数据库存储历史对话,根据当前输入检索语义相关的历史
 */
async function retrievalMemoryDemo() {
  try {
    console.log('连接到 Milvus...');
    await client.connectPromise;
    console.log('✓ 已连接\n');
  } catch (error) {
    console.error('❌ 无法连接到 Milvus:', error.message);
    console.log('请确保 Milvus 服务正在运行(localhost:19530)');
    return;
  }

  const history = new InMemoryChatMessageHistory();
  const conversations = [
    { input: "我之前提到的机器学习项目进展如何?" },
    { input: "我周末经常做什么?" },
    { input: "我的职业是什么?" },
  ];

  for (let i = 0; i < conversations.length; i++) {
    const { input } = conversations[i];
    const userMessage = new HumanMessage(input);

    console.log(`\n[第 ${i + 1} 轮对话]`);
    console.log(`用户: ${input}`);

    // 1. 检索相关的历史对话
    console.log('\n【检索相关历史对话】');
    const retrievedConversations = await retrieveRelevantConversations(input, 2);

    let relevantHistory = "";
    if (retrievedConversations.length > 0) {
      retrievedConversations.forEach((conv, idx) => {
        console.log(`\n[历史对话 ${idx + 1}] 相似度: ${conv.score.toFixed(4)}`);
        console.log(`轮次: ${conv.round}`);
        console.log(`内容: ${conv.content}`);
      });

      // 构建上下文
      relevantHistory = retrievedConversations
        .map((conv, idx) => {
          return `[历史对话 ${idx + 1}]
轮次: ${conv.round}
${conv.content}`;
        })
        .join('\n\n━━━━━\n\n');
    } else {
      console.log('未找到相关历史对话');
    }

    // 2. 构建 prompt(使用检索到的历史作为上下文)
    const contextMessages = relevantHistory
      ? [
          new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题: ${input}`)
        ]
      : [userMessage];

    // 3. 调用模型生成回答
    console.log('\n【AI 回答】');
    const response = await model.invoke(contextMessages);

    // 保存当前对话到历史消息
    await history.addMessage(userMessage);
    await history.addMessage(response);

    // 4. 将对话保存到 Milvus 向量数据库
    const conversationText = `用户: ${input}\n助手: ${response.content}`;
    const convId = `conv_${Date.now()}_${i + 1}`;
    const convVector = await getEmbedding(conversationText);

    try {
      await client.insert({
        collection_name: COLLECTION_NAME,
        data: [{
          id: convId,
          vector: convVector,
          content: conversationText,
          round: i + 1,
          timestamp: new Date().toISOString()
        }]
      });
      console.log(`💾 已保存到 Milvus 向量数据库`);
    } catch (error) {
      console.warn('保存到向量数据库时出错:', error.message);
    }

    console.log(`助手: ${response.content}`);
  }
}

retrievalMemoryDemo().catch(console.error);

我们调用大模型,问了一些问题("我之前提到的机器学习项目进展如何?"、"我周末经常做什么?"、"我的职业是什么?"),通过 RAG 流程来检索之前的对话,来生成回答。这样,不管之前多久聊过什么内容,都能够继续聊。

聊完之后我们把最新的聊天也存入了向量数据库,这样之后检索就知道我们这次聊了什么。这就是实现 Memory 的第三种策略,检索

总结 + 检索:组合使用

总结、检索,这俩策略经常同时用。比如你开发一个聊天应用:每聊 20 条就触发一次总结,生成摘要存入 Milvus 向量数据库。你问 AI "我们聊过什么内容",这时候 RAG 就是从 Milvus 的摘要集合里查找,生成回答。

常见问题

总结后的 message 要加入 history 吗? 肯定要——就是用总结替换之前的 message,而不是删掉就完了。

summarize 后 token 还是超出上限怎么办? 一般不会,Claude Code 和 Cursor 都有 token 限制,达到一定 token 就触发总结然后清空之前的 message,不会同时留着很多 memory。

每次请求都把全量内容给模型,很费 token? 你平时用的 Cursor 不也是这样么,不然它怎么知道之前聊过什么,只不过到了一定 token 会总结。另外第二轮确实会重复消耗第一轮的 token,但可以做缓存(cache)优化。

不同会话检索的内容会串掉吗? 用其他字段(比如 xx_id)先过滤,过滤完之后再做向量相似度匹配来检索——和用 MySQL 查询的思路一样,那边一个表怎么区分多个用户的数据,这里也一样,只不过匹配的时候是向量相似度匹配。

每次聊天记录都存向量库,下次获取时要先总结吗? 一般是的:总结后再存入向量数据库,检索出来的是总结,而不是原始的大段聊天记录。

getBufferString 的写法? Node 版是 getBufferString(messages, { humanPrefix: "用户", aiPrefix: "助手" }),不要用 Python 版的字符串参数写法,输出会不对。

对话中涉及 tool_call 行为怎么管理? 比如文件读写可以保留文件路径在对话里,用到的时候再让大模型去读。

text-embedding-v3 没免费额度了? 可以用 text-embedding-v4,或者付费(10 块钱能用很久)。

总结

大模型是无状态的,需要我们管理 Memory——也就是管理给它的 messages,它才能继续之前的话题聊。

  • 存储:LangChain 封装了 ChatMessageHistory 的 API 用来存储 messages,可以存在内存(InMemoryChatMessageHistory)、文件(FileSystemChatMessageHistory)、Redis、数据库等
  • API 演进:之前有各种 memory 的 API,现在都废弃了(不够灵活,完全可自己实现),现在只剩 history + trimMessages
  • 三种管理策略
    • 截断:超出一定条数、一定 token 数量就去掉之前的 message(trimMessages + js-tiktoken 计数)
    • 总结:调用大模型生成对话摘要,这样就可以删掉原始 message 了
    • 检索:结合向量数据库做语义检索,通过 RAG 来检索之前聊的内容
  • 我们常用的 Cursor 就是超出一定 token 会触发总结;也可以总结 + 检索组合:在 Milvus 中存储对话总结,然后结合检索来查找

管理好了 memory,就无论什么时候都可以基于之前的话题继续聊了,这是做 AI Agent 开发必须要做的。

基于 VitePress 构建 · 专注前端与 AI 实战